Skip to content

Uniform do* hooks and will/did notifications across every capability - #117

Merged
dccote merged 8 commits into
masterfrom
capability-do-hooks
Aug 4, 2026
Merged

Uniform do* hooks and will/did notifications across every capability#117
dccote merged 8 commits into
masterfrom
capability-do-hooks

Conversation

@dccote

@dccote dccote commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Three related changes: one rule for how a public method reaches a driver, one rule for what it announces, and the documentation and introspection needed to keep both from eroding.

1. getXxx() calls doGetXxx(), everywhere

The DAQ, lock-in and trigger capabilities declared their public method itself as the @abstractmethod, so a driver implemented getAnalogVoltage() directly and the library had no concrete method of its own to hang validation, notifications or error handling on. Spectrometer had the same shape.

Now every public method is concrete and delegates; only the do* hook is abstract. Hooks that are optional or that default to a composition of the others (doAcquireWaveform, doGetDemodulatedValues, doGetSupported*) stay concrete but keep the prefix.

Callers are unaffected — every public name and signature is unchanged. Driver authors rename their implementations to the hook; a missed one fails loudly at instantiation with TypeError naming it. LabjackDevice, SR830Device and OISpectrometer were migrated.

Driver-specific parameters would have been amputated by a fixed public signature, so configureStream(channels, sampleRate=None, **parameters) and getSpectrum(**parameters) forward extras to the hook — the SR830's sampleClock, the LabJack's deprecated scanRate and the Ocean Insight integrationTime all still arrive.

2. will/did notifications on every capability

Only three family bases posted notifications, by hand. A capability operation went by silently, and a hook that raised told no one.

Every capability now names an enum in its notification attribute, and every public method is wrapped in the new @notifies decorator — one line per method instead of six lines of hand-written try/post/post, which is what keeps the pattern intact across 67 hooks.

  • An operation that changes the instrument posts will<Stem> then did<Stem>. A read posts did only, so a voltage sampled in a loop costs one notification, not two. Spectrometer.getSpectrum is the one read that keeps a will, since an acquisition takes an integration time.
  • did<Stem> is posted whether the operation succeeded or not, so a will is always followed by its did. user_info carries the arguments by name plus "result" and "error", exactly one of which is set. The exception is re-raised untouchedSR830Device raises ValueError for an out-of-range Aux voltage and callers rely on it.
  • Capabilities related by inheritance share one enum (17 enums for 22 capabilities), so AnalogIOCapability.notification is AnalogOutputCapability.notification. This is load-bearing: notifcenter keys observers by member identity, so without sharing an observer would have to know whether a device mixed in the combined capability or the plain one.
  • Overhead on a read is ~1.6 us with no observer, ~2.0 us with one, against millisecond-scale device I/O.

3. Family bases brought into the same scheme

LinearMotionDevice, RotationDevice and PowerMeterDevice now go through the same decorator. PowerMeterNotification.didMeasure becomes didGetAbsolutePower, named after its hook. Motion keeps its grouped willMove/didMovemoveTo, moveBy and home are all "the stage is moving" to an observer, and the payload tells them apart.

CameraDeviceNotification is deliberately untouched: a capture session is a different shape, with imageCaptured firing per frame and start/stop bracketing a thread rather than one hook call.

Introspection and docs

  • allCapabilities() and capabilityInterface() enumerate the mixins and describe each as extends/publicAPI/hooks.
  • python -m hardwarelibrary --capabilities prints every capability with its API, its hooks and the notifications it posts, so the inventory is never maintained by hand.
  • The README's supported-hardware table was missing five drivers (VerdiGDevice, FieldMasterDevice, SR830Device, PwrUSBDevice, StellarNet) and the Millennia's USB identity; the class-hierarchy diagram was stale in the same way. Added a "Capabilities" section explaining the mixin pattern from first principles.
  • Fixed the README and skill-file notification examples, which still used the pre-migration addObserver / userInfo camelCase API and would have failed for anyone who copied them.

Tests

hardwarelibrary/tests/testCapabilities.py (new, 37 tests) enforces the invariants rather than describing them: no public method on a capability is abstract; no PhysicalDevice subclass declares an abstract method outside its do* hooks; every hook has its did member; only non-reads have a will; no member exists without a hook behind it; capabilities in one inheritance chain share an enum; and the posted sequences and payloads are checked on both the success and the failure path. That last guard is what caught a missing Spectrometer.notification during review.

Full suite: 522 passed, 249 skipped.

Breaking changes

Callers of the public API are unaffected. Observers and driver authors are not:

  • Driver authors rename capability implementations to their do* hook.
  • PowerMeterNotification.didMeasure is now didGetAbsolutePower.
  • LinearMotionNotification / RotationMotionNotification keep willMove/didMove, but user_info is now a dict: read user_info["position"] where the payload used to be the position tuple.
  • ACTION REQUIRED for the licenced StellarNet driver, which ships encrypted and is not in this repository: rename its getSpectrum / getSerialNumber to doGetSpectrum / doGetSerialNumber, or StellarNet() raises TypeError for the missing hooks.

All recorded in CHANGELOG.md under [Unreleased].

🤖 Generated with Claude Code

https://claude.ai/code/session_01YB8L3EykgRP2HxXx5qknMB

dccote and others added 8 commits August 4, 2026 12:46
Problem: the README's supported-hardware table listed 15 devices while the
tree holds 20 drivers. VerdiGDevice, FieldMasterDevice, SR830Device,
PwrUSBDevice and StellarNet were absent, the Millennia was listed without
its USB identity, and the class-hierarchy diagram was stale in the same way
(it also showed ThorlabsKinesisDevice as a sibling of ThorlabsDevice rather
than its subclass, and credited turnOn/setPower to the LaserSourceDevice
marker base). Nothing explained the capability mixins, which are the one
design decision a reader meets everywhere in the library.

Solution: complete the table from the drivers actually present, with a note
on the devices that share the generic FTDI 0403:6001 identity and must be
disambiguated by serialNumber or portPath. Add a "Capabilities" section that
builds the idea from first principles for a reader with no OO background:
the concrete problem (the Millennia has a shutter, the Cobolt does not), why
a fat base class or a class-per-combination both fail, the Capability/Device
naming convention, the public-method/do-hook split, and runtime
introspection with capabilities()/hasCapability(). Refresh the hierarchy
diagram, and add usage examples for the power strip and the other lasers,
since the README promises one per category.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Problem: the template method pattern was not uniform. The DAQ, lock-in and
trigger capabilities declared their public method itself as the
@AbstractMethod, so a driver implemented getAnalogVoltage() directly and the
library had no concrete method of its own to hang argument validation,
notifications or error handling on. Spectrometer had the same shape for
getSpectrum/getSerialNumber. Nothing enumerated the capabilities either, so
the only way to know what the library can express was to read the module.

Solution: every public method of every capability is now concrete and
delegates to a do* hook, and only the hook is abstract, in all nine DAQ /
lock-in / trigger mixins and in Spectrometer. Hooks that are optional or
that default to a composition of the others (doAcquireWaveform,
doGetDemodulatedValues, doGetSupported*, doConfigure*, do*Direction) stay
concrete but keep the do prefix. Public methods that carried driver-specific
parameters forward them: configureStream(channels, sampleRate=None,
**parameters) and getSpectrum(**parameters), so the SR830's sampleClock, the
LabJack's deprecated scanRate and the Ocean Insight integrationTime still
reach their driver. No public name or signature changed, so callers are
unaffected; LabjackDevice, SR830Device and OISpectrometer were migrated to
the hooks.

Add allCapabilities() and capabilityInterface() to enumerate the mixins and
describe each one as extends/publicAPI/hooks, plus a
`python -m hardwarelibrary --capabilities` listing built on them, so the
inventory is never maintained by hand. tests/testCapabilities.py covers both
and enforces the invariants: no public method on a capability is abstract,
no PhysicalDevice subclass declares an abstract method outside its do*
hooks, and no capability is declared outside capabilities.py. HOPSInterface
is exempt from the first two: it is a transport strategy behind
VerdiGDevice, closer to CommunicationPort than to a device API.

Note for the licenced StellarNet driver, which ships encrypted and is not in
this repository: rename its getSpectrum/getSerialNumber to
doGetSpectrum/doGetSerialNumber, or StellarNet() raises TypeError for the
missing hooks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Problem: only three family bases posted notifications (LinearMotionDevice,
PowerMeterDevice, CameraDevice), and they did it by hand. A capability
operation went by silently, so a GUI or a logger had to poll or wrap every
driver itself, and a hook that raised told no one. The one notification enum
that did exist for a capability, AnalogInputStreamCapability.Notification,
was never posted at all.

Solution: every capability now names a notification enum in its `notification`
attribute, and every public method is wrapped in the new @notifies decorator,
so a driver gets notifications by implementing hooks and writing no
notification code. One decorator line per method rather than six lines of
hand-written try/post/post, which is what keeps the pattern from eroding
across 67 hooks.

An operation that changes the instrument posts will<Stem> then did<Stem>; a
read (doGet*, doReadStream) posts only did<Stem>, since bracketing a value
that is merely being read doubles the traffic on hot paths like a voltage
sampled in a loop. Spectrometer.getSpectrum is the one read that keeps a
will, because an acquisition takes an integration time and a display wants
to know it started. Measured overhead on a read is ~1.6 us with no observer,
~2.0 us with one, against millisecond-scale device I/O.

did<Stem> is posted whether the operation succeeded or not, so a will is
always followed by its did and an observer never has to pair two different
members to know an operation ended. user_info carries the method's arguments
by name plus "result" and "error", exactly one of which is non-None: the
observer decides what to do from the error, while the exception itself is
re-raised untouched, because a driver's exception type is part of its
contract (SR830Device raises ValueError for an out-of-range Aux voltage, and
callers rely on it).

Capabilities related by inheritance share one enum, so `notification` is the
same object on all of them and their members are interchangeable:
AnalogInput/AnalogOutput/AnalogIO/AnalogInputStream all post
AnalogNotification, and the digital trio posts DigitalNotification (17 enums
for 22 capabilities). Sharing is what makes this work at all, because
notifcenter keys observers by enum member identity: two same-named members
of two enums would never cross-fire, and an observer would otherwise have to
know whether a device mixed in the combined capability or the plain one.

testCapabilities.py enforces the scheme: every hook has its did member, only
non-reads have a will, no member exists without a hook behind it, no enum
carries a separate failure member, capabilities in one chain share an enum,
and the posted sequences and payloads are checked on success and on failure.
`python -m hardwarelibrary --capabilities` now lists each capability's enum.

Also fixes the README's "Listening for device events" example, which still
used the pre-migration camelCase notifcenter API (addObserver, userInfo) and
would have failed for anyone who copied it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Problem: LinearMotionDevice, RotationDevice and PowerMeterDevice posted
notifications by hand, from before the capability scheme existed, and drifted
from it in three ways. PowerMeterNotification.didMeasure was named after its
public method rather than its hook (doGetAbsolutePower), unlike every
capability member. The payloads were bare values -- a position tuple, an
angle, a float -- where notifcenter documents user_info as a dict and every
capability now passes one. And none of them said anything when a driver
raised, so an observer saw a willMove with no matching didMove and could not
tell a slow move from a failed one.

Solution: all three now go through @notifies, so they follow one rule with
the capabilities and report failures for free. PowerMeterNotification.
didMeasure becomes didGetAbsolutePower, named after its hook. user_info is a
dict of the method's arguments by name plus "result" and "error", so a
handler reads user_info["position"] where it used to unpack the notification
payload directly. Each base names its enum in a `notification` attribute, as
the capabilities do; Spectrometer gained the attribute it was missing when
its enum was added.

Motion keeps its grouped willMove/didMove rather than splitting into
willMoveTo/willMoveBy/willHome: moveTo, moveBy and home are all "the stage is
moving" to an observer, and the payload already tells them apart, carrying a
position, a displacement, or neither. RotationMotionNotification gets the
same treatment, having had the identical shape before.

testCapabilities.py grows a guard for the family bases: their members are
named after their hooks (loosely, since a base may group several hooks under
one name), a read still posts did only, and every base that notifies is
covered by those checks. That guard is what caught the missing
Spectrometer.notification.

CameraDeviceNotification is deliberately left alone: a capture session is a
different shape, with imageCaptured firing per frame and start/stop
bracketing a thread rather than one hook call.

Also fixes the notification example in the project skill file, which used the
pre-migration import path and the camelCase addObserver/userInfo API.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Problem: calling an operation before initializeDevice() failed in two
different unhelpful ways. On real hardware it died deep inside the driver
with AttributeError: 'NoneType' object has no attribute 'writeString',
naming a port rather than the mistake. On a debug device it was worse: the
call succeeded, DebugMillenniaDevice().turnOn() reported the laser on while
the device was still Unconfigured, and it posted a didTurnOn with error None,
telling every observer the hardware had done something it had not.
PhysicalDevice.NotInitialized existed for exactly this and was raised in one
place, sendCommand.

Solution: @notifies calls validateReady() before anything else, so all 67
capability operations plus the family bases are guarded without a driver
writing a line. The error names the operation, the class and the state:
"Cannot turnOn() on DebugMillenniaEv25Device: the device is Unconfigured, not
Ready. Call initializeDevice() first." A device shut down again is refused
too, since its state returns to Recognized.

The check runs before the will is posted, not inside the try: nothing was
attempted and the hardware was never touched, so an observer hears nothing at
all rather than a will followed by a did carrying the error.

PhysicalDevice.validateReady is the real check and sits ahead of
Capability.validateReady, a no-op, in a driver's MRO. That is what lets a
capability still be exercised bare, as the tests do with a mixin that has no
device state, without weakening the check for anything that is a
PhysicalDevice.

Methods that only report what a model supports are exempt through
requiresReady=False -- supportedInputSources, supportedSensitivities,
supportedTimeConstants, supportedTriggerSources and outletCount all read
class attributes, and a UI populates its menus before connecting.

testCapabilities.py covers the guard, including that nothing is posted when
it refuses, that a shut-down device is refused again, and that the exempt
methods answer while Unconfigured. It also walks every capability's public
API rather than naming methods, so a capability added later cannot escape the
guard unnoticed. MockOISpectrometer, which bypasses PhysicalDevice.__init__
on purpose, now declares the Ready state it stands for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Problem: validateReady was defined twice, as a no-op on Capability and for
real on PhysicalDevice, with the MRO picking the right one. The no-op was
never reached by any driver in the library -- PhysicalDevice sits at MRO
index 2 and Capability at index 10, so it was shadowed everywhere it
mattered. It only ran for a capability mixed into something that is not a
device, and there it silently skipped the check. That also put a device
lifecycle concept in capabilities.py, which has no business defining one.

Solution: drop the fallback and keep the single definition on PhysicalDevice,
where the state machine lives; capabilities.py only calls it. Anything
hosting a capability without being a PhysicalDevice now has to answer for
readiness itself, which is the honest contract: every capability's docstring
already says to combine it with a PhysicalDevice.

The three test stubs that stand in for a device -- _RecordingAnalogDevice and
_FailingAnalogDevice, and _MinimalLockIn in testSR830 -- supply it, as they
already supply the hooks. The trade-off is the failure mode for a capability
mixed into a non-device: previously no validation at all, now an
AttributeError naming validateReady.

Note that delegating from Capability.validateReady to PhysicalDevice's would
not have worked: it is unreachable from any driver, and on a bare mixin it
raises AttributeError on the missing state attribute.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Problem: a public method passed whatever it was given straight to the driver,
so several calls were accepted and did nothing a caller would expect.
acquireWaveform(sampleCount=0) returned an empty acquisition, sampleRate=-100
was accepted, setDigitalValue("yes") set the line True, setSensitivity(-1)
and setTimeConstant(0) were silently snapped to a step, and
matisse.setWavelength(50.0) was sent to the birefringent-filter motor even
though the driver reports a 700-1000 nm range two methods below. An empty
channel list failed inside the drain loop with "min() iterable argument is
empty", naming nothing the caller had written. Two ad-hoc private validators
had grown in the drivers meanwhile, one in PwrUSBDevice and one in
LabjackDevice.

Solution: hardwarelibrary/validation.py collects the shared checks --
requireRealNumber, requireInteger, requireBool, requireAtLeast,
requirePositive, requireWithinRange, requireNonEmpty, requireMember -- each
raising TypeError for the wrong kind of value and ValueError for one out of
bounds, and each naming the parameter, the value and the bound it broke. A
capability declares a _validateXxx method and hands it to @notifies as
validate=, which runs it before anything is posted: a refused call announces
nothing, so a will/did pair still means the driver was invoked.

The line between the two levels is deliberate. Contract-level checks, true of
every device implementing the capability, live in the capability: a sample
count of at least one, a positive time constant, a wavelength inside the
range the driver itself reports. Limits that vary by model stay in the
driver, next to the numbers they come from -- the SR830's +/-10.5 V Aux
output and the Millennia's 0.05-25 W are untouched. Channel identity is
checked by neither: an SR830 addresses its outputs with AuxOutput members
where a LabJack uses bare ints, so there is no shared rule to write.

A validator calls do* hooks rather than public methods, so validating never
posts a notification of its own: the wavelength check reads
doGetWavelengthRange(), the outlet check doGetOutletCount(). That last one
subsumes PwrUSBDevice._validateOutlet, which is removed, so every future
strip inherits the bounds instead of reimplementing them.

setInputSource and setTriggerSource now accept anything their enum accepts
and hand the driver a member, so setInputSource("Differential") works.

No dependency was added. pydantic, beartype and icontract were considered:
none can express the device-dependent bounds, which are half the checks here,
and each raises its own exception type where the drivers and their tests
rely on ValueError.

One test changed meaning: the SR830 external-clock case passed sampleRate=0
to mean "the rate is not mine", where the documented spelling is None. It now
says None.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dccote
dccote merged commit a35f984 into master Aug 4, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant